fix(server): terminate streamable HTTP sessions on manager shutdown - #3218
fix(server): terminate streamable HTTP sessions on manager shutdown#3218baiyuxi930826 wants to merge 1 commit into
Conversation
Why: On CTRL+C / lifespan exit with live SSE clients, StreamableHTTPSessionManager cancelled the task group without terminating transports, and terminate() left _sse_stream_writers open — uvicorn then logs "ASGI callable returned without completing response" (modelcontextprotocol#2150). - Manager run() finally: await terminate() on active transports before cancel - terminate(): close all SSE writers first (incl. GET + provisional replay keys) - Register GET standalone writer immediately; provisional key during replay - Regression tests for writer close + manager shutdown path Rebased onto current main after modelcontextprotocol#3125 was closed unmerged during v2 backlog cleanup (maintainers pointed at modelcontextprotocol#2253 on v1.x; this targets main with tests). Signed-off-by: Atlas/luccc-grok-4.5 <atlas@agent-studio.local>
There was a problem hiding this comment.
2 issues found across 3 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/server/streamable_http_manager.py">
<violation number="1" location="src/mcp/server/streamable_http_manager.py:158">
P1: A session created while this loop awaits an earlier `terminate()` is omitted from the snapshot and reaches task-group cancellation with its SSE response still open. Serialize creation against shutdown (or set/check a shutdown flag before creating transports) so every accepted session is terminated before cancellation.</violation>
</file>
<file name="src/mcp/server/streamable_http.py">
<violation number="1" location="src/mcp/server/streamable_http.py:738">
P1: Concurrent standalone GETs can still both pass the one-stream check because it only observes `_request_streams`, which is populated later by the writer task. The second request overwrites this newly registered writer, and either response's cleanup can then remove the other registration; that leaves a live SSE response that `terminate()` cannot close. Consider making the duplicate-stream guard use the writer registration (or reserving the GET key atomically) and only removing a mapping when it still refers to that handler's writer.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| logger.info("StreamableHTTP session manager shutting down") | ||
| # Terminate active transports before cancelling the task group so | ||
| # in-flight SSE responses can complete cleanly (issue #2150). | ||
| active_transports = list(self._server_instances.values()) |
There was a problem hiding this comment.
P1: A session created while this loop awaits an earlier terminate() is omitted from the snapshot and reaches task-group cancellation with its SSE response still open. Serialize creation against shutdown (or set/check a shutdown flag before creating transports) so every accepted session is terminated before cancellation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/streamable_http_manager.py, line 158:
<comment>A session created while this loop awaits an earlier `terminate()` is omitted from the snapshot and reaches task-group cancellation with its SSE response still open. Serialize creation against shutdown (or set/check a shutdown flag before creating transports) so every accepted session is terminated before cancellation.</comment>
<file context>
@@ -153,6 +153,18 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]:
logger.info("StreamableHTTP session manager shutting down")
+ # Terminate active transports before cancelling the task group so
+ # in-flight SSE responses can complete cleanly (issue #2150).
+ active_transports = list(self._server_instances.values())
+ for transport in active_transports:
+ if not transport.is_terminated: # pragma: no branch
</file context>
| # Create SSE stream | ||
| sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[SSEEvent](0) | ||
| # Register immediately so session terminate() can close this ASGI response. | ||
| self._sse_stream_writers[GET_STREAM_KEY] = sse_stream_writer |
There was a problem hiding this comment.
P1: Concurrent standalone GETs can still both pass the one-stream check because it only observes _request_streams, which is populated later by the writer task. The second request overwrites this newly registered writer, and either response's cleanup can then remove the other registration; that leaves a live SSE response that terminate() cannot close. Consider making the duplicate-stream guard use the writer registration (or reserving the GET key atomically) and only removing a mapping when it still refers to that handler's writer.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/streamable_http.py, line 738:
<comment>Concurrent standalone GETs can still both pass the one-stream check because it only observes `_request_streams`, which is populated later by the writer task. The second request overwrites this newly registered writer, and either response's cleanup can then remove the other registration; that leaves a live SSE response that `terminate()` cannot close. Consider making the duplicate-stream guard use the writer registration (or reserving the GET key atomically) and only removing a mapping when it still refers to that handler's writer.</comment>
<file context>
@@ -734,6 +734,8 @@ async def _handle_get_request(self, request: Request, send: Send) -> None:
# Create SSE stream
sse_stream_writer, sse_stream_reader = anyio.create_memory_object_stream[SSEEvent](0)
+ # Register immediately so session terminate() can close this ASGI response.
+ self._sse_stream_writers[GET_STREAM_KEY] = sse_stream_writer
async def standalone_sse_writer():
</file context>
Summary
Fixes #2150 on current
main.When shutting down a Streamable HTTP server (CTRL+C / lifespan exit) with live SSE clients, uvicorn logs:
Root causes (still present on
main@a4f4ccd)StreamableHTTPSessionManager.run()finallycancelled the task group and cleared_server_instanceswithoutawait transport.terminate()on active sessions.StreamableHTTPServerTransport.terminate()closed request/read/write streams but left_sse_stream_writersopen, soEventSourceResponsenever completed.Fix
terminate(): close all SSE writers first viaclose_sse_stream(covers POST request streams, GET standalone, and provisional replay keys).Relation to other work
main.v1.x, has no regression tests, and is stale (last update 2026-04-25). This PR is themain+ tests line maintainers invited.Verification
Test plan
test_streamable_http_manager.pysuite green locally (34)